iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
自我挑戰組

富士大顆系列 第 23

vol. 23 Rails 的「測試-測試-測試!」:筆試直接寫起來!(下)

  • 分享至 

  • xImage
  •  

你好,我是富士大顆 Aiko
今天就拿最近的筆試題目來進行吧!


題目1:有時候我們會使用 redis 來設置數據。下面的代碼有一部分沒有完成,但是 rspec 還留著, 請你幫忙實作沒有完成的部分

# app > redis > max_ordianal_number.rb
class MaxOrdinalNumber
 EXPIRE_DAY = 1
 EXPIRE_SEC = 60 * 60 * 24 * EXPIRE_DAY
 KEY_PREFIX = "max_ordinal_number"

 def initialize
   # ...
 end

 def get
   # ...
 end

 def set
   # ...
 end
end
# spec > redis > max_ordinal_number_spec.rb
require "rails_helper"

RSpec.describe MaxOrdinalNumber do
 let(:screening_id) { "margin_sell_up" }
 let(:redis_key) { "#{described_class::KEY_PREFIX}:#{screening_id}" }

 describe "#set and #get" do
   let(:max_ordinal_number) { described_class.new(screening_id) }
   let(:number) { 5 }
   before { max_ordinal_number.set(number) }
   
   it { expect(REDIS.ttl(redis_key)).to eq 86400 } # 60 * 60 * 24 * 1
   it { expect(max_ordinal_number.get).to eq 5 }
 end
end

運用 TDD 概念

所以我先從 _spec.rb 來閱讀,實際需要產出的結果是什麼

require "rails_helper"

RSpec.describe MaxOrdinalNumber do
 let(:screening_id) { "margin_sell_up" }
# `let` 在 `rspec` 功能就是宣告變數,而這邊就是宣告 `:screening_id` = `margin_sell_up`
 let(:redis_key) { "#{described_class::KEY_PREFIX}:#{screening_id}" }
# 比較複雜,但其實也是一樣的,`redis_key` 由 `KEY_PREFIX` 和 `screening_id` 組成的;
# described_class 是 RSpec 的一個特殊方法,它會回傳目前被描述的類別(現在是 MaxOrdinalNumber)
# 綜合以上,redis_key 可能的值會是 "max_ordinal_number:margin_sell_up"
​
 describe "#set and #get" do
 #題目重點喔!因為我們要寫的就是 #set, #get, #initialize
 #所以這邊就是讀取 redis 跟寫入 redis 的方法測試
   let(:max_ordinal_number) { described_class.new(screening_id) }
   # 宣告 `max_ordinal_number` = `MaxOrdinalNumber`.new(附帶 screening_id 做為參數)
   let(:number) { 5 }
   before { max_ordinal_number.set(number) }
   # 在 #set 之前,就把 number 設為 5

   it { expect(REDIS.ttl(redis_key)).to eq 86400 } # 60 * 60 * 24 * 1
   # .ttl 是 redis 的用法,也就是預期 Redis 中對應的 redis_key 的 TimeToLive(TTL)應為 86400 秒。
   it { expect(max_ordinal_number.get).to eq 5 }
   # 預期從 redis 拿出的值是 5
 end
end

總結

  • 使用 let 來宣告一些變數和物件,這些會在測試中被使用。
  • 使用 describe 來組織測試,專注於 #set 和 #get 方法。
  • 使用 before 來執行一些預設操作,這個例子中是呼叫 #set 方法來設定一個值。
  • 最後,它使用 it 來定義實際的測試,例如要得到什麼結果 expect。

這個測試主要驗證兩件事:

  1. Redis 中對應的 redis_key 的存活時間(TTL)是否為 86400 秒。#set
  2. 從 Redis 中使用 #get 方法取出的值是否為 5。

開始來建檔案!

用 VSCode 隨意開一個資料夾,我的話是:/Users/bagel.florence/fn_test
開個檔案:max_ordinal_number_spec.rb
然後把題目貼上檔案去

輸入$gem install rspec

安裝 Rspec

持續 $rspec & 程式碼修改

不知道為什麼用 rspec log 會說 No examples found.
所以改用直接指定檔名 rspec max_ordinal_number_spec.rb

會出現這樣的錯誤訊息:

表示 Ruby 無法找到名為 MaxOrdinalNumber 的 class
解決的辦法可以是:

  • 確定 MaxOrdinalNumber class 的是在測試運行之前被載入的。
  • 如果 MaxOrdinalNumber class 在另一個檔案中,可能需要在測試檔案的頂部使用 require 來載入它。
  • 檢查類別名稱和檔案名稱是否正確。Ruby 的命名規則是嚴格的,確定沒有拼錯或使用不正確的大小寫。

由於我還沒新增 max_ordinal_number.rb
所以它找不到,所以我新增,然後貼上題目:

class MaxOrdinalNumber
 EXPIRE_DAY = 1
 EXPIRE_SEC = 60 * 60 * 24 * EXPIRE_DAY
 KEY_PREFIX = "max_ordinal_number"

 def initialize
   
 end

 def get
   
 end

 def set
   
 end
end

並且在 rspec.rb 檔開頭就使用 require_relative 'max_ordinal_number'
(因為他們在同一個資料夾,不然要有完整絕對路徑)

再跑一次 rspec

錯誤不一樣了!

顯示 initialize 方法期望 0 個參數,但實際上你傳遞了 1 個。
這是什麼意思?

對的,你需要在 initialize 方法中接收一個參數 screening_id
這樣當 rspec 中使用 described_class.new(screening_id) new 新的 MaxOrdinalNumber 實體變數時,這個 screening_id 參數就會被傳遞到 initialize 方法。

rb 中的 initialize 目前是空的
沒有任何 block, 也沒有設定要接收任何參數

那我們先完整它:

def initialize(screening_id)
#設定執行時要接收 screening_id 這個參數
  @screening_id = screening_id
#要有裝 screening_id 的容器
  @redis_key = "#{KEY_PREFIX}:#{@screening_id}"
#同樣的在 rspec 一開始有提到的 redis_key 也要有容器裝
end

這樣,screening_id 會被傳遞到 initialize 方法,並設置為實例變數 @screening_id。在其他方法(如 getset)中使用它了。

ok, 繼續 rspec
新的錯誤!至少往下移了:

set 的參數數量不對,set 方法期望 0 個參數,但在測試中你傳遞了 1 個參數(number)
為什麼參數是 number?

因為在 rspec 裡,before { max_ordinal_number.set(number) }呼叫了 set 方法並傳遞了一個名為 number 的參數。這個 number 參數是由另一個 let(:number) { 5 } 宣告所設定的。因此,set 方法應該接收一個名為 number 的參數。

所以 rb 的 set:

def set(number)
end

給它 number 參數

再繼續 rspec!
新的錯誤~

這指出了兩個問題:

  1. REDIS 沒有被初始化
  2. expected: 5 got: nil:表示 max_ordinal_number.get 回傳了 nil,而不是預期的 5。
    這可能是因為 set 和 get 方法在 MaxOrdinalNumber class 中還沒有寫...(的確)

查詢一下 Redis

因為題目跟 Redis 有關,該知道的還是要知道!
Redis 的文件有夠長,在時間緊要的關係下,要縮小閱讀的範圍...

重新確認下題目的 expect:

it { expect(REDIS.ttl(redis_key)).to eq 86400 } # 60 * 60 * 24 * 1

翻成白話就是:我們期待 REDIS.ttl 會等於 86400 (秒);剩餘過期時間是 24 小時!
.ttl 是屬於 Redis 的用法,用於查詢一個已經設定的 key 的 TTL。
這個時間以秒為單位。所以,set 方法的時候,要一起設定 TTL

所以是跟 ttl 有關的,且要存進去 Redis (能查詢就是已經寫進去啦)前,先設定

在 Redis 中,.setex 方法是用來設定帶有 TTL 的 key-value pair 的。

如果你需要設定一個 key-value pair 而不需要過期時間,你可以使用 .set 方法。
但因為需求中明確要求有過期時間,所以 .setex 是更適合的選擇。

.setex

在 Redis 中設定一個帶有 TTL(以秒為單位)的 key-value pair。

這個方法接受三個參數:

  1. key:要設定的 key。
  2. time_to_live:key 的 TTL,以秒為單位。
  3. value:與 key 相關聯的 value。

參數必須按照順序

試著把 code 按照說明帶入:

@redis.setex(@redis_key, EXPIRE_SEC, number)
  • @redis_key 是要設定的 key。
  • EXPIRE_SEC 是 key 的 TTL。的確名稱跟 TTL 的意思一樣
  • number 是與 key 相關聯的 value。

這段程式碼的意思是設定了一個 key-value pair 之後,這個 pair 會在 EXPIRE_SEC 秒後自動從 Redis 中刪除。

因為 @redis.setex 這樣寫,也要在 initialize 方法寫個容器裝這個“值”,因此:

@redis = Redis.new

安裝個 redis: $gem install redis
再 rspec
結果問題就變了:

redis 看起來有問題,連不上去

那試試看把 require 'redis' 加到兩個檔案的頭部裡面去吧
...
(後省)

Rspec again and again

TDD 就是這樣的一個過程!
直到把所有的問題都解決了,測試才剛開始
因為後續還要一起測試相關的整合功能測試

這段過程考驗的是耐性還有“閱讀錯誤”以及解決問題的能力
總之,一起加油吧!

如果你也愛上測試(??
推薦羅伯特大神的 RSpec 系列
你會對測試有更深的理解!


題目2:下方 UsMarket 為 Service Object ,主要拿來判斷美股開盤或收盤時間,但因專案預設時區並非美國紐約時區,所以必須將現在時間先轉換為美國紐約時區後才能判斷,請您完成它,並補上測試。請注意下列幾點:

  • 美國股市開盤時間為美國紐約時間早上 09:30 點 ~ 下午 16:00 點
  • 方法以及測試描述,請謹慎思考過後再命名
  • 美國紐約時區為 "America/New_York"
  • 因為本次測試與時間相關,為了保證測試完全正確,需要使用 Timecop gem 輔助測試,請自行查閱該如何使用。
  • 繳交文字檔即可
# services/us_market.rb
class UsMarket
  # class method 1 判斷現在是否為美股開盤時間,回傳值 type 為 boolean
  # class method 2 判斷現在是否為美股收盤時間,回傳值 type 為 boolean 
end
# spec/services/us_market_spec.rb
RSpec.describe UsMarket do
    # 請補上對應測試,請記得此測試不管任何時間執行都必須要完全正確
end


分析題目

這次題目的字很多,但不要緊張,我們一段段看:

  • 有一個 UsMarket 的 ServiceObject,ServiceObject 的特性還記得嗎?
  • 主要判斷美股開盤或收盤時間 T/F => 所以 expect 回傳 boolean
  • 專案預設時間跟美國("America/New_York")不一樣,考官建議要用 Timecop gem
  • 除了要寫 class methods (兩個:開盤/收盤的判斷)還要完成 RSpec,且命名要合理
  • 美國股市開盤時間為美國紐約時間 09:30 ~ 16:00 ;反之就是收盤時間

What's ServiceObject

之前的文章有探討:vol. 19 Rails 裡的 helper, Service Object, Concern 到底怎麼用?

What's Timecop

官方文件
timecop 是用在 test 環境的 gem
針對時間計算
主要有三個方法:freeze travel scale
freeze:凍結時間到指定時刻

Timecop.freeze(Date.today + 30) do
  assert joe.mortgage_due?
end

travel:到特定的時間點(不一定是過去可以是未來),時間從那裡可以繼續前進

Timecop.travel(Time.local(2008, 9, 1, 10, 5, 0))
end
Timecop.travel(Time.now + 3.days) do
  puts Time.now  
# 會顯示未來三天後的時間
end

scale:加速或減速時間的流逝速度,縮放因子為 1 時,時間流逝正常;縮放因子大於 1 時,時間會加速流逝;縮放因子小於 1 但大於 0 時,時間會減速。

Timecop.scale(3600)
# 縮放因子為 3600(即一小時的秒數,因此每過一秒鐘,Time.now 會報告時間已經過去了一小時

根據題目需求,我們應該只要用到 freeze

現在時間如何轉換為美國紐約時區?

關於時間,Ruby 有一整個 Time 的 class 表示時間。這個類別提供了多種方法來操作和查詢時間。以下是一些基本用法:

新增時間對象

# 當前時間
current_time = Time.now

# 特定時間(年、月、日、時、分、秒,時區)
specific_time = Time.new(2021, 10, 10, 12, 0, 0, "+09:00")

時間的單位元素

你可以使用以下方法來獲取時間的不同部分:

time = Time.now

time.year    # 年
time.month   # 月
time.day     # 日
time.hour    # 小時
time.min     # 分鐘
time.sec     # 秒
time.wday    # 星期的第幾天(0 是星期日,1 是星期一,等等)

時間運算

Ruby 的 Time 類別也可以進行基本時間運算:

# 增加時間
future_time = Time.now + 3600  # 現在時間加上 3600 秒(1 小時)

# 減少時間
past_time = Time.now - 3600  # 現在時間減去 3600 秒(1 小時)

時區轉換

如果你需要做時區轉換,Ruby 的 Time 類別可能不是最方便的選擇。但目前依據題目的需求,Time 很夠用惹!
處理更複雜的時區問題,例如一直換時區,或是有日光節約時間,則可以用 TZInfo

require 'tzinfo'

# 得到紐約時間
ny_time = TZInfo::Timezone.get('America/New_York').now

格式化時間

Time 類別提供了一個 strftime 方法,能以不同格式顯示時間:

time = Time.now
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S")

Time 類別有很多其他功能和方法,可以參考 Ruby 官方文件

Rails 也有關於時間的方法

在這題,Time 可以搭配 in_time_zone 使用


上一篇
vol. 22 Rails 的「測試-測試-測試!」:「環境」怎麼設定?(中)
下一篇
vol. 24 資料庫也可以很多型:資料庫類型與資料模型
系列文
富士大顆30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言